Flutter Navigator와 선언형 라우팅 비교

Flutter Navigator와 선언형 라우팅 비교

한눈에 보기

명령형 Navigator는 현재 스택에 pushpop을 수행하므로 짧은 선형 흐름에 단순하다. 선언형 Router는 URL과 앱 상태를 입력으로 받아 보여야 할 Page stack을 계산하므로 딥링크, 인증 redirect, Flutter web history, 여러 Navigator를 다루기 쉽다. 둘 중 하나를 전면 금지하기보다 공유 가능한 목적지는 선언형 route, 일시적인 dialog는 pageless route처럼 책임을 나누는 편이 실용적이다.

두 번째 화면을 여는 가장 익숙한 Flutter 코드는 다음과 같다.

final result = await Navigator.of(context).push<EditResult>(
  MaterialPageRoute(
    builder: (context) => EditPage(itemId: itemId),
  ),
);

현재 사용자 행동에 바로 대응하고 반환값도 Future로 받을 수 있어 이해하기 쉽다. 화면이 몇 개 없는 모바일 앱이라면 이것만으로 충분할 수 있다.

하지만 앱이 커지면 요구가 달라진다.

여기서 중요한 차이는 API 문법이 아니라 화면 스택의 진실의 원천이 어디에 있는가다.

목차

Navigator는 Route 객체의 stack을 관리한다.

flowchart LR
    A["Home Route"] --> B["List Route"]
    B --> C["Detail Route"]
    C --> D["Edit Route
top"]

push는 top에 Route를 추가하고 pop은 top Route를 제거한다.

void openDetails(BuildContext context, String itemId) {
  Navigator.of(context).push(
    MaterialPageRoute<void>(
      builder: (context) {
        return DetailPage(itemId: itemId);
      },
    ),
  );
}

이 방식의 장점은 명확하다.

작은 앱에서 사용자가 Home→Detail→Edit로만 이동한다면 명령형 호출이 가장 단순할 수 있다.

문제는 stack을 복원해야 할 때다. /records/42/edit deep link 하나를 받았을 때 Home, Detail, Edit 중 어떤 stack을 만들어야 하는지 여러 push 호출로 재현해야 한다. 인증 상태가 중간에 바뀌면 흩어진 호출을 다시 조정해야 한다.

명령형은 낡은 API가 아니다

선언형 Router를 사용하는 앱에서도 dialog, menu, 일시적인 상세 overlay에 Navigator push를 함께 사용할 수 있다. 요구가 다른 두 도구다.

선언형 라우팅은 상태에서 stack을 계산한다

선언형 방식에서는 “Edit Route를 push한다”보다 “현재 location은 /records/42/edit이고 사용자는 로그인되어 있다”를 입력으로 화면 stack을 만든다.

pages = f(location, authState, featureFlags)

개념적인 Navigator.pages 예시는 다음과 같다.

List<Page<void>> buildPages(AppRoute route) {
  return [
    const MaterialPage(
      key: ValueKey('home'),
      child: HomePage(),
    ),
    if (route case RecordRoute(:final recordId))
      MaterialPage(
        key: ValueKey('record-$recordId'),
        child: RecordPage(recordId: recordId),
      ),
    if (route case EditRecordRoute(:final recordId))
      MaterialPage(
        key: ValueKey('edit-$recordId'),
        child: EditRecordPage(recordId: recordId),
      ),
  ];
}

route state가 달라지면 새 Page 목록과 이전 목록을 비교해 Navigator가 Route stack을 갱신한다. Widget과 Element 관계처럼 Page는 Route의 configuration 역할을 한다.

실무에서는 RouteInformationParser와 RouterDelegate를 모두 직접 구현하기보다 go_router 같은 routing package를 주로 사용한다.

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/records/:recordId',
      builder: (context, state) {
        final recordId = state.pathParameters['recordId']!;
        return RecordPage(recordId: recordId);
      },
    ),
  ],
);
MaterialApp.router(
  routerConfig: router,
)

상세 API는 go_router 버전에 따라 달라질 수 있지만 핵심은 location을 parsing해 Page stack과 동기화한다는 점이다.

어떤 흐름에 어느 방식이 자연스러운가

요구 명령형 Navigator 선언형 Router
화면 두세 개의 선형 이동 단순함 설정 비용이 더 큼
route 결과를 기다리는 picker 자연스러움 가능하지만 모델링 필요
외부 deep link 직접 parsing·stack 복원 핵심 사용 사례
웹 주소 표시줄 직접 동기화 어려움 Router와 History 연동
인증에 따른 redirect 호출 위치가 분산될 수 있음 route 상태 함수로 중앙화
하단 tab별 history nested Navigator 직접 구성 Shell route로 모델링 가능
화면 상태 복원 stack 명령 기록 필요 location과 앱 상태로 계산
dialog·bottom sheet 자연스러움 보통 imperative pageless route 사용

작은 앱이라고 무조건 Navigator, 큰 앱이라고 무조건 go_router는 아니다. 다음 질문이 더 정확하다.

두 개 이상이 중요하다면 선언형 route tree의 가치가 커진다.

URL을 화면 상태의 일부로 다루기

딥링크 가능한 화면은 URL만으로 핵심 목적지를 표현해야 한다.

/records/42
/records/42/edit
/search?q=flutter&page=2

URL에는 다음 종류의 상태를 넣을 수 있다.

위치 용도
path parameter resource identity /records/42
query parameter 선택적 filter·sort·page ?sort=recent&page=2
fragment 문서 내부 위치 #comments
navigation extra 일시적 최적화 데이터 이미 로드한 preview 객체

route를 열 때는 문자열 이어 붙이기보다 package가 제공하는 parameter encoding 또는 typed route를 검토한다.

context.go(
  Uri(
    path: '/search',
    queryParameters: {
      'q': keyword,
      'page': '1',
    },
  ).toString(),
);

수신 측에서는 값이 존재한다고 바로 신뢰하지 않는다.

final rawPage = state.uri.queryParameters['page'];
final page = int.tryParse(rawPage ?? '') ?? 1;

if (page < 1 || page > 1000) {
  return const InvalidSearchRoutePage();
}

route parameter는 사용자 입력이다. 잘못된 UUID, 너무 큰 page, 존재하지 않는 resource, 접근 권한 없는 ID를 정상 조건으로 처리해야 한다.

서버 resource의 존재 여부를 route parser가 동기적으로 모두 확인하려 하지 않는다. 형식 검증은 route 경계에서 하고 loading·not found·forbidden은 화면 데이터 상태로 표현할 수 있다.

경로 파라미터와 extra의 차이

이미 목록에서 RecordSummary를 가지고 있으니 객체 전체를 extra로 넘기면 상세 화면이 빨리 열릴 수 있다.

context.go(
  '/records/${record.id}',
  extra: record,
);

하지만 앱이 cold start deep link로 직접 열리거나 웹 페이지를 새로고침하면 메모리에 있던 객체가 없다. URL만으로 복원할 수 없는 값을 route의 필수 입력으로 만들면 직접 진입이 깨진다.

더 안전한 구조는 ID를 필수 source로 두고 extra는 선택적 초기 화면 최적화로 취급하는 것이다.

final recordId = state.pathParameters['recordId']!;
final preview = state.extra is RecordSummary
    ? state.extra! as RecordSummary
    : null;

return RecordPage(
  recordId: recordId,
  initialPreview: preview,
);

상세 화면은 preview가 없어도 repository에서 ID로 데이터를 가져올 수 있어야 한다. preview가 있더라도 최신 서버 데이터로 검증하거나 갱신한다.

객체 extra는 URL 계약이 아니다

공유 링크, browser refresh, process restart를 통과해야 하는 상태는 path나 query 또는 영속 상태로 표현한다.

민감한 토큰과 개인정보를 URL에 넣지 않는다. URL은 browser history, analytics, referrer, screenshot, server log에 남을 수 있다.

인증 redirect를 상태 함수로 만들기

명령형 앱에서는 여러 화면의 init 또는 API 401 처리에서 login route를 push하기 쉽다.

if (!session.isSignedIn) {
  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (_) => const SignInPage(),
    ),
  );
}

호출이 분산되면 login 화면이 중복으로 쌓이거나 뒤로가기 했을 때 보호 화면이 다시 보일 수 있다.

선언형 router에서는 location과 인증 상태로 목적지를 계산한다.

String? authRedirect({
  required Uri uri,
  required AuthState auth,
}) {
  final isSignInRoute = uri.path == '/sign-in';
  final requiresAuth = uri.path.startsWith('/account');

  if (auth.isChecking) {
    return '/session-check';
  }

  if (!auth.isSignedIn && requiresAuth) {
    final returnTo = Uri.encodeComponent(uri.toString());
    return '/sign-in?returnTo=$returnTo';
  }

  if (auth.isSignedIn && isSignInRoute) {
    return '/';
  }

  return null;
}

라우터 callback에서는 이 pure decision을 호출한다.

redirect: (context, state) {
  return authRedirect(
    uri: state.uri,
    auth: authState.value,
  );
},

redirect는 여러 번 평가될 수 있으므로 analytics 전송, token refresh 시작 같은 부수 효과를 직접 넣지 않는다. 같은 입력에는 같은 결과를 내는 함수로 유지하면 loop를 테스트하기 쉽다.

redirect loop가 생기는 전형적인 경우는 다음과 같다.

/account → 로그인 필요 → /sign-in
/sign-in → 보호 route의 returnTo 즉시 적용 → /account
/account → 아직 session 갱신 전 → /sign-in

인증 상태를 checking, signedOut, signedIn으로 구분하고 session 확인이 끝나기 전에 확정 redirect를 반복하지 않는다.

stateDiagram-v2
    [*] --> Checking
    Checking --> SignedOut
    Checking --> SignedIn
    SignedOut --> SignInRoute
    SignInRoute --> SignedIn: 로그인 성공
    SignedIn --> IntendedRoute

returnTo는 open redirect가 되지 않도록 앱 내부에서 허용한 상대 경로인지 검증한다.

go와 push를 의도에 맞게 선택하기

go_router 계열 API에는 location으로 이동하는 go와 현재 stack 위에 추가하는 push가 함께 있다. 세부 동작은 route tree와 버전에 따라 확인해야 하지만 의도는 구분할 수 있다.

go

현재 location을 새 location으로 바꾸고 route configuration과 맞는 stack을 만든다.

context.go('/records/$recordId');

탭 전환, deep link 목적지, 인증 후 canonical location 이동처럼 URL 상태를 전환하는 데 적합하다.

push

현재 route stack 위에 새 page를 추가하고 나중에 결과를 받는 흐름에 적합하다.

final selected = await context.push<String>(
  '/labels/picker',
);

공유 가능한 상세 화면도 현재 탐색 문맥에서 overlay처럼 쌓고 싶다면 push가 자연스러울 수 있다.

replace

로그인 완료 뒤 로그인 page를 history에서 제거하거나 edit 저장 후 canonical detail route로 바꾸는 경우를 고려한다.

context.replace('/records/$recordId');

선택 기준은 method 이름이 아니라 뒤로가기로 어디에 돌아가야 하는지다.

사용자 기대 의도
이전 화면으로 돌아감 push
앱의 현재 목적지를 전환 go
현재 history 항목을 대체 replace

브라우저 history 반영 방식은 router option과 버전에 따라 달라질 수 있으므로 Flutter web에서 실제 주소와 back/forward를 확인한다.

Nested Navigator와 하단 탭

하단 tab 앱에서는 각 tab의 탐색 history를 유지하고 싶을 수 있다.

Home tab:    /home → /home/article/42
Search tab:  /search?q=flutter → /search/filter
Profile tab: /profile → /profile/settings

하나의 Navigator에서 tab 화면만 바꾸면 다른 tab으로 이동할 때 이전 상세 stack을 잃을 수 있다. tab마다 nested Navigator를 두면 각각의 stack을 유지할 수 있다.

go_router의 ShellRouteStatefulShellRoute 계열은 공통 shell 아래 여러 Navigator를 구성하는 데 사용된다. 정확한 API는 설치 버전을 확인한다.

ShellRoute(
  builder: (context, state, child) {
    return AppShell(child: child);
  },
  routes: [
    GoRoute(
      path: '/home',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/profile',
      builder: (context, state) => const ProfilePage(),
    ),
  ],
)

결제 전체 화면이나 이미지 viewer를 하단 navigation 위에 띄우려면 root Navigator를 사용해야 할 수 있다. 어느 Navigator에 page를 놓을지 navigator key와 parent relationship을 명시한다.

Nested Navigator는 강력하지만 다음 문제를 추가한다.

UI 구조만 보고 도입하지 말고 back 정책을 먼저 문서화한다.

Page-backed route와 pageless route를 섞을 때

Router가 Navigator.pages에서 만든 Route는 page-backed route다. showDialog나 직접 Navigator.push로 추가한 Route는 대응하는 Page가 없는 pageless route일 수 있다.

flowchart TD
    P1["Page: Record"] --> R1["Page-backed Route"]
    R1 --> D["Pageless Dialog"]

아래의 page-backed route가 선언형 update로 제거되면 그 위에 붙은 pageless route도 함께 제거될 수 있다.

예를 들어 Detail page 위에 삭제 확인 dialog가 열려 있는데 deep link나 auth redirect가 Detail을 stack에서 제거하면 dialog도 닫힐 수 있다. 이는 선언형 state와 stack을 맞추기 위한 자연스러운 결과지만 dialog Future를 기다리는 코드에서는 null 결과와 context 수명을 처리해야 한다.

final confirmed = await showDialog<bool>(...);

if (!context.mounted || confirmed != true) return;
await deleteRecord();

모든 overlay를 URL route로 만들 필요는 없다.

뒤로가기와 완료 결과 설계

명령형 route는 반환값을 받기 쉽다.

final label = await Navigator.of(context).push<String>(
  MaterialPageRoute(
    builder: (_) => const LabelPickerPage(),
  ),
);

if (!context.mounted || label == null) return;
setState(() => selectedLabel = label);

선언형 router도 push 계열 API로 결과를 지원할 수 있지만 version API를 확인한다. URL 기반 목적지는 반환값보다 공유 상태나 resource ID를 통해 결과를 반영하는 편이 자연스러울 때도 있다.

예를 들어 Edit page에서 저장 후 Detail로 돌아가면 다음 선택지가 있다.

작업 규모와 deep link 요구에 맞춘다.

시스템 뒤로가기, app bar back, browser back, gesture back이 같은 의미를 가져야 한다. 저장되지 않은 draft가 있다면 PopScope와 routing package의 현재 API를 확인해 이탈 확인 정책을 구현한다.

뒤로가기를 무조건 막지 않는다

사용자에게 저장·버리기·계속 편집 선택을 제공하고 Android predictive back 같은 플랫폼 동작을 실제 기기에서 검증한다.

오류·복원·분석을 라우팅에 포함하기

route table은 정상 경로 목록만이 아니다.

Not found

알 수 없는 URL, 삭제된 resource, 잘못된 parameter를 구분한다.

/records/not-a-valid-id  → invalid route parameter
/records/known-format    → loading → not found
/unknown/path            → route not found

권한 없음

로그인하지 않음과 로그인했지만 접근 권한 없음은 다른 상태다. 후자를 login redirect loop로 보내지 않는다.

State restoration

process가 종료된 뒤 어떤 route stack과 tab 위치를 복원할지 정한다. URL만으로 충분한 상태와 restoration ID가 필요한 일시 상태를 구분한다.

Analytics

각 버튼에서 screen view를 전송하면 deep link와 browser back을 놓칠 수 있다. Router observer 또는 location 변경을 관측하되 redirect 중간 route와 중복 event를 어떻게 처리할지 정한다.

로그

route template과 결과 상태를 기록하되 query의 개인정보와 token을 그대로 로그에 남기지 않는다.

{
  "route": "/records/:recordId",
  "result": "not_found",
  "source": "deep_link"
}

실제 ID 대신 template을 사용하면 cardinality와 개인정보 노출을 줄일 수 있다.

테스트 전략

선언형 routing의 장점 중 하나는 route 결정을 pure function으로 분리해 테스트할 수 있다는 것이다.

test('signed-out user is redirected to sign-in', () {
  final result = authRedirect(
    uri: Uri.parse('/account/settings'),
    auth: const AuthState.signedOut(),
  );

  expect(
    result,
    startsWith('/sign-in?returnTo='),
  );
});

route integration test에서는 실제 navigation 결과를 본다.

testWidgets('deep link opens the record page', (tester) async {
  final router = createTestRouter(
    initialLocation: '/records/42',
  );

  await tester.pumpWidget(
    MaterialApp.router(routerConfig: router),
  );
  await tester.pumpAndSettle();

  expect(find.byType(RecordPage), findsOneWidget);
  expect(find.text('Record 42'), findsOneWidget);
});

다음 행렬을 작성하면 누락을 줄일 수 있다.

시작 상태 입력 location 기대 결과
signed out /account sign-in + 안전한 returnTo
signed in /sign-in home 또는 의도한 목적지
checking 보호 route session loading
signed in, forbidden /admin forbidden page
signed in 잘못된 ID invalid/not found
web refresh nested detail URL 같은 화면 stack 복원

Nested navigation에서는 tab 전환 뒤 각 branch의 back stack 보존, root dialog, 시스템 back 순서를 테스트한다.

마이그레이션과 선택 체크리스트

기존 Navigator 앱을 한 번에 모두 바꿀 필요는 없다.

  1. 외부에서 직접 열어야 하는 핵심 destination을 식별한다.
  2. path·query schema와 오류 정책을 정의한다.
  3. root에 Router를 도입하고 destination을 page-backed route로 옮긴다.
  4. dialog와 picker 같은 국소 흐름은 imperative로 유지한다.
  5. 인증 redirect를 pure decision으로 중앙화한다.
  6. browser history와 mobile deep link를 실제 환경에서 검증한다.
  7. 흩어진 named route와 navigation helper를 단계적으로 제거한다.

Flutter 공식 문서는 대부분의 애플리케이션에서 named route를 권장하지 않는다. deep link customize와 browser forward 지원 제한이 있기 때문이다. 단순히 문자열 이름을 route package로 옮기는 것이 아니라 URL과 stack 모델을 다시 설계한다.

명령형 방식이 충분한가?

선언형 routing이 필요한가?

운영 검증

마무리

Navigator와 선언형 Router의 차이는 push 대신 go를 쓰는 정도가 아니다. 명령형 방식에서는 현재 stack에 어떤 작업을 할지가 중심이고, 선언형 방식에서는 URL과 앱 상태로 어떤 stack이어야 하는지를 계산한다.

짧은 모바일 흐름, picker, dialog처럼 현재 사용자 행동에 종속된 전환은 imperative Navigator가 단순하다. deep link, 인증 redirect, Flutter web history, nested navigation처럼 외부 상태에서 stack을 복원해야 하면 선언형 Router가 유리하다.

두 방식을 함께 사용할 때는 경계를 정한다. 공유하고 복원해야 하는 destination은 page-backed route로 만들고, 특정 page에 잠깐 종속되는 dialog는 pageless route로 둔다. path와 query는 검증하며 extra에 필수 상태를 의존하지 않는다.

라우팅 방식은 화면 수가 아니라 “현재 화면을 어떤 입력으로 다시 만들어야 하는가”를 기준으로 선택한다. URL과 앱 상태가 진실의 원천이라면 선언형으로, 현재 stack에 대한 국소적인 사용자 명령이라면 Navigator로 표현한다.

관련 노트

참고 자료